iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
Software Development

從 Fragment 到 Compose — 老 Android App 重寫的架構取捨系列 第 27 篇

Day 27|前景定位服務與 Compose 的溝通

  • 分享至 

  • xImage
  •  

動筆前先答(兩題)

1.【預測再驗證|生命週期與程序】 把舊 App 的三層接力(前景服務 → 綁定管理員 → ViewModel → LiveData)搬進新架構,最省事的骨架大概是這樣:core:location 只露一個介面,app 用前景服務實作,畫面用 LifecycleStartEffect 說「我需要定位了/不需要了」:

// core:location
interface LocationTracker {
    val location: StateFlow<Location?>
    fun acquire()   // 一個畫面開始需要定位
    fun release()   // 那個畫面不再需要
}

// app:用前景服務實作
@Singleton
class ServiceLocationTracker @Inject constructor(
    @ApplicationContext private val context: Context,
) : LocationTracker {
    private val _location = MutableStateFlow<Location?>(null)
    override val location = _location.asStateFlow()
    private var clients = 0

    private val connection = object : ServiceConnection {
        override fun onServiceConnected(name: ComponentName, binder: IBinder) {
            val service = (binder as LocationService.LocalBinder).service
            CoroutineScope(Dispatchers.Main).launch {
                service.location.collect { _location.value = it }
            }
        }
        override fun onServiceDisconnected(name: ComponentName) = Unit
    }

    override fun acquire() {
        if (clients++ == 0) {
            val intent = Intent(context, LocationService::class.java)
            context.startForegroundService(intent)
            context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
        }
    }

    override fun release() {
        if (--clients == 0) context.unbindService(connection)
    }
}

// feature:map
@Composable
fun TaskMapScreen(viewModel: TaskMapViewModel = hiltViewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    LifecycleStartEffect(Unit) {
        viewModel.onEvent(TaskMapUiEvent.Shown)
        onStopOrDispose { viewModel.onEvent(TaskMapUiEvent.Hidden) }
    }
    // ViewModel 在 Shown 時 tracker.acquire()、Hidden 時 tracker.release()
}

服務本身照舊 App 的規則:最後一個客戶端解除綁定時先停掉定位更新,兩秒寬限期後沒人回來就自己停掉並收通知;onStartCommand 回 START_STICKY,但收到 null intent 時若沒有客戶端就 stopSelf()。

下面每個情境,先寫下你的預測與依據,再驗證(本專案的 demo flavor 加一個暫時的畫面就夠;通知欄、adb shell dumpsys activity services <package>、adb shell am kill <package>、Android 14 以上的實機或模擬器都用得到):

  1. 從地圖畫面導到任務列表,兩個畫面都需要定位。離開畫面的 onStopOrDispose 和新畫面的 LifecycleStartEffect,哪個先跑?clients 有沒有一瞬間是 0?服務的兩秒寬限期會不會啟動、通知會不會閃一下?NavHost 有轉場動畫和沒有轉場動畫,答案一樣嗎?
  2. 在地圖畫面按 Home 鍵,十分鐘後回來。回來那一刻 clients 是多少、服務還活著嗎、通知還在嗎?acquire() 在 Activity 的 onStart 時間點呼叫 startForegroundService(),Android 12 以上允許嗎?如果使用者這十分鐘在系統設定把定位權限關掉了,Android 14 上 startForeground(type = location) 會丟什麼?哪一層接得到、地圖畫面看到什麼?
  3. adb shell am kill <package> 殺掉程序(不要用「不保留活動」,那不會殺程序)。START_STICKY 讓系統用 null intent 重啟服務,骨架會做什麼?三十秒後通知欄有沒有東西?沒有客戶端的服務被重啟又自殺,宣告 START_STICKY 的意義是什麼?onTaskRemoved 在這個流程裡有沒有被叫到?
  4. onServiceConnected 裡那個 CoroutineScope(Dispatchers.Main).launch { … collect } 沒有人取消。綁定、解除綁定五次之後,活著的 collector 有幾個?解除綁定後 _location 是停在最後一個值,還是變回 null?使用者揹著手機走五百公尺再回到 App,地圖第一幀畫的是哪個位置?LeakCanary 預設不監看 Service 物件,所以這個情境它會不會叫?不會的話你要怎麼抓?
  5. 在地圖畫面旋轉螢幕。@Singleton 的 tracker 活著,但 LifecycleStartEffect 會先 Hidden 再 Shown,clients 走 1 → 0 → 1。服務的寬限期計時器啟動了嗎?「因為 Activity 要重建而 release」和「因為使用者離開而 release」,tracker 分得出來嗎?分不出來的話,Activity.isChangingConfigurations 這個資訊該不該進 core:location?

最後回答:這五個情境裡,哪幾個可以在 JVM 單元測試裡用假的服務連線對著 tracker 驗?哪幾個要 Robolectric 或 instrumented test?哪幾個只能實機驗、要寫進發版前的手動檢查清單?

我的回答:(待補)

2.【決策邊界|規格與驗收】 舊 App 的定位是這樣接的:一個約 310 行的前景服務握著 fused client(30 秒一次、最快 15 秒、高精度),對外露一個 StateFlow<Location?>;一個約 110 行的管理員負責綁定服務,在一個永遠不取消的 Main scope 裡把服務的 StateFlow 轉抄進自己的 StateFlow;一個 AndroidViewModel 再轉抄一次成 LiveData;四個畫面觀察它:地圖(我的位置)、智慧排序列表(顯示車輛座標)、拖曳排序、簽到。基底 Activity 在 onResume 時有權限就啟動並綁定、onStop 時解除綁定;服務在最後一個客戶端離開兩秒後自己停掉;一個 static volatile 的「服務在跑」旗標擋重複啟動。manifest 同時宣告了背景定位權限。

簽到畫面讀的是 liveData.value:按下簽到那一刻拿不到定位(null 或 0,0)就改送公司總部的座標,照樣送出。而且簽到畫面用的是自己那一份 ViewModel 實例,它背後的管理員從來沒有人叫它綁定服務。

新架構要先決定「定位屬於誰」。三個選項:

  • (A) 不要服務:core:location 只是把 fused client 包成 callbackFlow 的 Flow<Location>,畫面在 STARTED 時收集、離開就停;沒有通知、沒有背景追蹤。
  • (B) 服務是唯一來源:單例 tracker 綁前景服務,所有畫面只看它的 StateFlow,誰都不直接碰 fused client;登入後 App 在前景就有通知。
  • (C) 兩段式:平常走 (A);只有「配送中」這個業務狀態才升級成前景服務。兩段共用同一個 LocationRequest 與同一個 Flow 介面,畫面分不出現在是哪一段。

不看下面的大綱,依序回答:

  1. 把四個畫面的定位用途列出來,每一個標上「畫面在前景就夠」還是「螢幕關掉也要繼續」。如果沒有任何一個需要後者,前景通知在跟使用者解釋什麼?寫出你會放進 Play 政策審查表單、說明為什麼需要 foregroundServiceType="location" 的那一句話。寫不出來的話,剩下哪個選項?
  2. 簽到的「夠新鮮」:定義年齡上限、精度上限、以及完全沒有定位時怎麼辦(擋下簽到、放行但標記、用系統的最後已知位置)。「拿不到就送總部座標」這條規則在資料裡留下了什麼?把你的定義寫成一條驗收條件,要讓注入時鐘的 JVM 測試抓得到「過期」這個邊界。再回答:「有一個消費者永遠拿到 null」這種事,新架構要靠哪一層讓它在測試裡藏不住?
  3. 權限矩陣:精確/概略(Android 12 起使用者可以只給概略)、拒絕、僅限這次、背景定位。三個選項各要處理哪幾格?權限流程住在 feature 還是 core:location?沒有權限時 core:location 對外是 null、一個錯誤狀態,還是把「沒權限/關閉定位/有值」做成 sealed 型別讓畫面不得不處理?
  4. 生命週期的歸屬:在 (B) 和 (C) 裡,誰負責 acquire/release:每個畫面各自的 effect,還是 app 層依「已登入」或「配送中」統一管?把「地圖與列表之間切換通知不閃爍」「旋轉不重啟服務」寫成兩條驗收條件。這兩條在 (A) 裡還存在嗎?
  5. Day 26–28 是可裁切項。如果 Day 27 只能交 (A),你的最低驗收是什麼?什麼數字(每小時耗電、定位年齡、簽到時拿到定位的比例)會讓 (C) 從可選變成必要?這個數字你打算從哪裡拿:實際裝置的資料,還是先定一個再量?

最後用兩句話收尾:你的結論,以及你願意承認的代價。

我的回答:(待補)


上一篇
Day 26|地圖與叢集:兩個「就緒」的競態
下一篇
Day 28|雙方握手的狀態機
系列文
從 Fragment 到 Compose — 老 Android App 重寫的架構取捨 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言